Published on

RAG检索增强生成(二)

Authors
  • avatar
    Name
    游戏人生
    Twitter

大规模数据预处理

受限于常见 llm 的上下文大小,例如 gpt3.5 是 16k、gpt4 是 128k,不能把完整的数据整个塞到对话的上下文中,而且,即使数据源接近于 llm 的上下文窗口大小,llm 在读取数据时也很容易忽略其中的细节。所以需要对加载进来的数据切分,切分成较小的语块,然后根据对话的内容,将关联性比较强的数据塞到 llm 的上下文中,来强化 llm 输出的质量。

langchain 目前提供的切分工具:

名称说明
Recursive根据给定的切分字符(例如 \n\n、\n等),递归的切分
HTML根据 html 特定字符进行切分
Markdown根据 md 的特定字符进行切分
Code根据不同编程语言的特定字符进行切分
Token根据文本块的 token 数据进行切分
Character根据用户给定的字符进行切割

RecursiveCharacterTextSplitter

RecursiveCharacterTextSplitter,最常用的切分工具,根据内置的一些字符对原始文本进行递归的切分,来保持相关的文本片段相邻,保持切分结果内部的语意相关性。默认的分隔符列表是 ["\n\n", "\n", " ", ""]。

影响切分质量的两个参数:

  • chunkSize: 默认是 1000,切分后的文本块的大小,如果文本块太大,llm 的上下文窗口会不够,如果文本块太小,llm 的上下文窗口会太小,导致 llm 输出的结果不够准确。
  • chunkOverlap: 默认是 200,切分后的文本块之间的重叠长度,如果文本块重叠的长度太长,llm 的上下文窗口会太小,导致llm 输出的结果不够准确。
  import { RecursiveCharacterTextSplitter } from "langchain/text_splitter";
  import { TextLoader } from "langchain/document_loaders/fs/text";

  const loader = new TextLoader("./data/2.txt");
  const docs = await loader.load();

  const splitter = new RecursiveCharacterTextSplitter({
      chunkSize: 64,
      chunkOverlap: 0,
    });

  const splitDocs = await splitter.splitDocuments(docs);
  console.log(splitDocs);

输出内容如下:

  [
    Document {
      pageContent: "鲁镇的酒店的格局,是和别处不同的:都是当街一个曲尺形的大柜台,柜里面预备着热水,可以随时温酒。做工的人,傍午傍晚散了工,每每花四",
      metadata: { source: "./data/2.txt", loc: { lines: { from: 1, to: 1 } } }
    },
    Document {
      pageContent: "文铜钱,买一碗酒,——这是二十多年前的事,现在每碗要涨到十文,——靠柜外站着,热热的喝了休息;倘肯多花一文,便可以买一碟盐煮笋,",
      metadata: { source: "./data/2.txt", loc: { lines: { from: 1, to: 1 } } }
    },
    Document {
      pageContent: "或者茴香豆,做下酒物了,如果出到十几文,那就能买一样荤菜,但这些顾客,多是短衣帮,大抵没有这样阔绰。只有穿长衫的,才踱进店面隔壁",
      metadata: { source: "./data/2.txt", loc: { lines: { from: 1, to: 1 } } }
    },
    ...
  ]

可以在 ChunkViz 查看文本切分效果。

Code

langchain 所支持的语言是一直在变动的,可以通过这个函数查询目前支持的语言:

  import { SupportedTextSplitterLanguages } from "langchain/text_splitter";

  console.log(SupportedTextSplitterLanguages); 

切分 js 代码案例:

  import { RecursiveCharacterTextSplitter } from "langchain/text_splitter";

  const js = `
    function myFunction(name, job){
      console.log("Welcome " + name + ", the " + job);
    }
    
    myFunction('小明','司机');
    
    function forFunction(){
      for (let i = 0; i < 5; i++){
            console.log("当前数字是" + i)
      }
    }
    
    forFunction();
    `;

  const splitter = RecursiveCharacterTextSplitter.fromLanguage("js", {
    chunkSize: 64,
    chunkOverlap: 0,
  });

  const jsOutput = await splitter.createDocuments([js]);
  console.log(jsOutput);

输出内容如下:

  [
    Document {
      pageContent: "function myFunction(name, job){",
      metadata: { loc: { lines: { from: 2, to: 2 } } }
    },
    Document {
      pageContent: 'console.log("Welcome " + name + ", the " + job);\n    }',
      metadata: { loc: { lines: { from: 3, to: 4 } } }
    },
    Document {
      pageContent: "myFunction('小明','司机');",
      metadata: { loc: { lines: { from: 6, to: 6 } } }
    },
    Document {
      pageContent: "function forFunction(){\n    \tfor (let i = 0; i < 5; i++){",
      metadata: { loc: { lines: { from: 8, to: 9 } } }
    },
    Document {
      pageContent: 'console.log("当前数字是" + i)\n    \t}\n    }',
      metadata: { loc: { lines: { from: 10, to: 12 } } }
    },
    Document {
      pageContent: "forFunction();",
      metadata: { loc: { lines: { from: 14, to: 14 } } }
    }
  ]

对 js 的分割本质就是将 js 中常见的切分代码的特定字符传给 RecursiveCharacterTextSplitter,然后还是根据 Recursive 的逻辑进行切分。

Token

根据 token 的数量进行切分,仅适合对 token 比较敏感的场景,或者与其他切分函数组合使用。

  import { TokenTextSplitter } from "langchain/text_splitter";

  const text = "From then on, I stood at the counter all day, focusing on my duties. Although I didn't fail to do my job, I always felt a little monotonous and bored. The shopkeeper had a fierce face, and the customers were not in a good mood, which made people feel depressed. Only when Kong Yiji came to the store could I laugh a few times, so I still remember it.";

  const splitter = new TokenTextSplitter({
    chunkSize: 20,
    chunkOverlap: 0,
  });

  const docs = await splitter.createDocuments([text]);
  console.log(docs);

输出内容如下:

  [
    Document {
      pageContent: "From then on, I stood at the counter all day, focusing on my duties. Although I didn",
      metadata: { loc: { lines: { from: 1, to: 1 } } }
    },
    Document {
      pageContent: "'t fail to do my job, I always felt a little monotonous and bored. The shop",
      metadata: { loc: { lines: { from: 1, to: 1 } } }
    },
    Document {
      pageContent: "keeper had a fierce face, and the customers were not in a good mood, which made people feel",
      metadata: { loc: { lines: { from: 1, to: 1 } } }
    },
    Document {
      pageContent: " depressed. Only when Kong Yiji came to the store could I laugh a few times, so I",
      metadata: { loc: { lines: { from: 1, to: 1 } } }
    },
    Document {
      pageContent: " still remember it.",
      metadata: { loc: { lines: { from: 1, to: 1 } } }
    }
  ]

构建向量数据库

Embedding

Embedding 是将文本转换为向量表示的过程,向量表示可以进行相似性搜索。

langchain 提供了多种 Embedding 模型,包括 OpenAI Embedding、AlibabaTongyiEmbeddings、BaiduQianfanEmbeddings 等。

首先将小说切分成块:

  import { TextLoader } from "langchain/document_loaders/fs/text";
  import { RecursiveCharacterTextSplitter } from "langchain/text_splitter";

  const loader = new TextLoader("./data/2.txt");
  const docs = await loader.load();

  const splitter = new RecursiveCharacterTextSplitter({
    chunkSize: 100,
    chunkOverlap: 20,
  });

  const splitDocs = await splitter.splitDocuments(docs);
  console.log(splitDocs[0].pageContent);

这样切分出来的块较小,也会节约 embedding 时的花费。参考自langchain.js

  import { AlibabaTongyiEmbeddings } from "@langchain/community/embeddings/alibaba_tongyi";
  // import { BaiduQianfanEmbeddings } from "@langchain/community/embeddings/baidu_qianfan";

  const model = new AlibabaTongyiEmbeddings({
    modelName: "text-embedding-v2", // text-embedding-v1, text-embedding-async-v1, text-embedding-async-v2
  });

  // const modal = new BaiduQianfanEmbeddings({
  //   modelName: "embedding-v1", // "embedding-v1" | "bge_large_zh" | "bge-large-en" | "tao-8k";
  // })

  const res = await model.embedQuery(splitDocs[0].pageContent);
  console.log({ res });

输出内容如下:

  [
    0.0059506547612028894,    0.006405104322379424,    0.04041307981709012,
    -0.01886294990158835,   -0.039227559222716556,   0.011387583931510565,
    0.014779490076523833,      0.0457084051386254, -0.0027415163744888794,
    0.010537960838876172,   -0.002453369007800859,   0.017795981366652138,
    -0.00467951323501345,   -0.008693817692072843,   -0.04162494531356088,
    0.011611515599336684,    -0.00843036867110094,  -0.032009056048086376,
    0.03506506469136047,   -0.013119761244400835,   0.021220818639286893,
    0.033826854292792516,    0.025844348957343815,   0.005384239366113295,
    0.05226828576082582,    0.010202063337136994,  -0.006125189737596776,
    -0.017875016072943707,    0.033326301152945895,   0.030718155845324044,
    0.011888137071357182,    0.014384316545065976,    0.00448851269480882,
    0.023670894534325602,
    ... 1436 more items
]

创建 MemoryVectorStore

MemoryVectorStore 是一个内存中的向量数据库,将文档和向量存储在内存中,方便后续查询。 embedding 向量是需要有一定花费的,所以仅在学习和测试时使用 MemoryVectorStore,而在真实项目中,搭建其他向量数据库,或者使用云数据库。

创建 MemoryVectorStore 的实例,传入需要 embeddings 的模型,调用添加文档的函数 addDocuments,langchain 的 MemoryVectorStore 会自动完成对每个文档请求 embeddings 的模型,然后存入数据库。

  import { MemoryVectorStore } from "langchain/vectorstores/memory";

  const vectorstore = new MemoryVectorStore(model);
  await vectorstore.addDocuments(splitDocs);

创建一个 retriever,这也是可以直接从 vector store 的实例中自动生成的,传入参数 2,代表对应每个输入,需要返回相似度最高的两个文本内容。

  const retriever = vectorstore.asRetriever(2);

  // 提取文档内容
  const res = await retriever.invoke("茴香豆是做什么用的");
  console.log(res);

为了提高回答质量,返回更多的数据源是有价值的

构建本地 vector store

因为数据生成 embedding 需要一定的花费,所以把 embedding 的结果持久化,这样就可以在应用中持续复用。 推荐使用 facebook 开源的 faiss 向量数据库,既支持 js,也支持 python。

示例如下: 首先创建 node 项目,安装依赖

  yarn add dotenv faiss-node langchain

将 txt 文件 embeddings,存入 db 文件夹内的文件中

  import { TextLoader } from "langchain/document_loaders/fs/text";
  import { RecursiveCharacterTextSplitter } from "langchain/text_splitter";
  import "dotenv/config";
  import { FaissStore } from "@langchain/community/vectorstores/faiss";
  import { AlibabaTongyiEmbeddings } from "@langchain/community/embeddings/alibaba_tongyi";

  const run = async () => {
    const loader = new TextLoader("./data/2.txt");
    const docs = await loader.load();

    const splitter = new RecursiveCharacterTextSplitter({
      chunkSize: 100,
      chunkOverlap: 20,
    });

    const splitDocs = await splitter.splitDocuments(docs);

    const embeddings = new AlibabaTongyiEmbeddings({});
    const vectorStore = await FaissStore.fromDocuments(splitDocs, embeddings);

    const directory = "./db/2";
    await vectorStore.save(directory);
  };

  run();

重新新建一个文件来加载存储好的 vector store:

  import "dotenv/config";
  import { FaissStore } from "@langchain/community/vectorstores/faiss";
  import { AlibabaTongyiEmbeddings } from "@langchain/community/embeddings/alibaba_tongyi";

  const directory = "./db/2";
  const embeddings = new AlibabaTongyiEmbeddings({});
  const vectorstore = await FaissStore.load(directory, embeddings);
  const retriever = vectorstore.asRetriever(2); // 根据相似度返回相关内容
  const res = await retriever.invoke("孔乙己是干什么的");
  console.log(res);

输出内容如下:

  [
    {
      pageContent: '孔乙己喝过半碗酒,涨红的脸色渐渐复了原,旁人便又问道,“孔乙己,你当真认识字么?”孔乙己看着问他的人,显出不屑置辩的神气。他们便接着说道,“你怎的连半个秀才也捞不到呢?”孔乙己立刻显出颓唐不安模样,',
      metadata: { source: './data/2.txt', loc: [Object] }
    },
    {
      pageContent: '洗。他对人说话,总是满口之乎者也,叫人半懂不懂的。因为他姓孔,别人便从描红纸上的“上大人孔乙己”这半懂不懂的话里,替他取下一个绰号,叫作孔乙己。孔乙己一到店,所有喝酒的人便都看着他笑,有的叫道,“孔乙',
      metadata: { source: './data/2.txt', loc: [Object] }
    }
  ]

参考自

retriever 常见优化方式

在 llm 中,如果用户提问的关键词缺少,或者跟原文中的关键词不一致,就容易导致 retriever 返回的文档质量不高,影响 llm 的最终输出效果。 因此可以对 retriever 进行优化,来提高返回内容与用户提问的相关性及内容质量。

MultiQueryRetriever

MultiQueryRetriever 是一个 retriever,它将输入的查询拆分成多个查询,然后分别调用基础的 retriever,然后合并结果。 MultiQueryRetriever 的构造函数需要传入基础的 retriever,以及拆分查询的函数。 MultiQueryRetriever 的 invoke 函数接收一个查询,然后调用基础的 retriever,合并结果。

  import { FaissStore } from "@langchain/community/vectorstores/faiss";
  import { AlibabaTongyiEmbeddings } from "@langchain/community/embeddings/alibaba_tongyi";
  import { ChatAlibabaTongyi } from "@langchain/community/chat_models/alibaba_tongyi";
  import { MultiQueryRetriever } from "langchain/retrievers/multi_query";
  import "faiss-node";
  import "dotenv/config";

  async function run() {
    const directory = "./db/2";
    const embeddings = new AlibabaTongyiEmbeddings({});
    const vectorstore = await FaissStore.load(directory, embeddings);

    const model = new ChatAlibabaTongyi({
      model: "qwen-turbo", // Available models: qwen-turbo, qwen-plus, qwen-max
      temperature: 1,
    });
    const retriever = MultiQueryRetriever.fromLLM({
      llm: model,
      retriever: vectorstore.asRetriever(3), // 每次会检索三条数据,对每个 query
      queryCount: 3, // 默认值是 3,表示对每条输入,llm 都会改写生成三条不同的写法和措词,表示同样意义的 query
      verbose: true, // 设置为 true 会打印出 chain 内部的详细执行过程方便 debug
    });
    const res = await retriever.invoke("茴香豆是做什么用的");

    console.log(res);
  }

  run();

Document Compressor

Retriever 的另一个问题,如果设置 k(每次检索返回的文档数)较小,因为自然语言的特殊性,可能相似度排名较高的并不是答案,就像搜索引擎依靠的也是相似性的度量,但排名最高的并不一定是最高质量的答案。而如果设置的 k 过大,就会导致大量的文档内容,可能会撑爆 llm 上下文窗口。

ContextualCompressionRetriever 是一个 retriever,它将输入的文档进行压缩,然后调用基础的 retriever。

  import { FaissStore } from "@langchain/community/vectorstores/faiss";
  import { AlibabaTongyiEmbeddings } from "@langchain/community/embeddings/alibaba_tongyi";
  import { ChatAlibabaTongyi } from "@langchain/community/chat_models/alibaba_tongyi";
  import "dotenv/config";
  import { LLMChainExtractor } from "langchain/retrievers/document_compressors/chain_extract";
  import { ContextualCompressionRetriever } from "langchain/retrievers/contextual_compression";

  // process.env.LANGCHAIN_VERBOSE = "true";

  async function run() {
    const directory = "./db/2";
    const embeddings = new AlibabaTongyiEmbeddings();
    const vectorstore = await FaissStore.load(directory, embeddings);

    const model = new ChatAlibabaTongyi({
      model: "qwen-turbo", // Available models: qwen-turbo, qwen-plus, qwen-max
      temperature: 1,
    });
    const compressor = LLMChainExtractor.fromLLM(model);

    const retriever = new ContextualCompressionRetriever({
      baseCompressor: compressor,
      baseRetriever: vectorstore.asRetriever(2),
    });
    const res = await retriever.invoke("茴香豆是做什么用的");
    console.log(res);
  }

  run();

核心参数

  • baseCompressor: 压缩上下文时会调用 chain,接收任何符合 Runnable interface 的对象
  • baseRetriever: 在检索数据时用到的 retriever

ScoreThresholdRetriever

ScoreThresholdRetriever 是一个 retriever,它将输入的查询和文档进行匹配,然后根据匹配得分进行过滤。

  import { FaissStore } from "@langchain/community/vectorstores/faiss";
  import { AlibabaTongyiEmbeddings } from "@langchain/community/embeddings/alibaba_tongyi";
  import "dotenv/config";
  import { ScoreThresholdRetriever } from "langchain/retrievers/score_threshold";

  // process.env.LANGCHAIN_VERBOSE = "true";

  async function run() {
    const directory = "./db/kongyiji";
    const embeddings = new AlibabaTongyiEmbeddings();
    const vectorstore = await FaissStore.load(directory, embeddings);

    const retriever = ScoreThresholdRetriever.fromVectorStore(vectorstore, {
      minSimilarityScore: 0.8,
      maxK: 3,
      kIncrement: 1,
    });
    const res = await retriever.invoke("茴香豆是做什么用的");
    console.log(res);
  }

  run();

核心参数:

  • minSimilarityScore: 定义了最小的相似度阈值,即文档向量和 query 向量相似度达到多少,就认为是可以被返回的。这个要根据文档类型设置,一般是 0.8 左右,可以避免返回大量的文档导致消耗过多的 token
  • maxK: 一次最多返回多少条数据,主要是为了避免返回太多的文档造成 token 过度的消耗
  • kIncrement: 定义了算法的布厂,你可以理解成 for 循环中的 i+k 中的 k。其逻辑是每次多获取 kIncrement 个文档,然后看这 kIncrement 个文档的相似度是否满足要求,满足则返回

基于私域数据进行回答

构建一个基于本地 txt 文件,作为私域数据,来回答问题的 RAG bot。

加载环境变量

  import { load } from "dotenv";
  const env = await load();

  const process = {
      env
  };

加载文章数据

  import { TextLoader } from "langchain/document_loaders/fs/text";

  const loader = new TextLoader("./data/qiu.txt");
  const docs = await loader.load();

切割数据

  import { RecursiveCharacterTextSplitter } from "langchain/text_splitter";

  const splitter = new RecursiveCharacterTextSplitter({
      chunkSize: 500,
      chunkOverlap: 10,
    });

  const splitDocs = await splitter.splitDocuments(docs);

构建 vector store 和 retriever

  import { AlibabaTongyiEmbeddings } from "@langchain/community/embeddings/alibaba_tongyi";

  const embeddings = new AlibabaTongyiEmbeddings({});
  import { MemoryVectorStore } from "langchain/vectorstores/memory";

  const vectorstore = new MemoryVectorStore(embeddings);
  await vectorstore.addDocuments(splitDocs);
  const retriever = vectorstore.asRetriever(2);

测试提问

  const res = await retriever.invoke("原文中,谁提出了宏原子的假设?并详细介绍宏原子假设的理论");
  console.log(res);

添加处理函数,将回答处理成普通文本

  import { RunnableSequence } from "@langchain/core/runnables";
  import { Document } from "@langchain/core/documents";

  const convertDocsToString = (list) => {
    return list.map((document) => document.pageContent).join("\n")
  };

  const contextRetriverChain = RunnableSequence.from([
    (input) => input.question,
    retriever,
    convertDocsToString
  ]);

RunnableSequence 构建了一个简单的 chain,传入一个数组,并且把第一个 Runnable 对象返回的结果自动传入给后面的 Runnable 对象。 contextRetriverChain,接收一个 input 对象作为输入,获得其 question 属性,传递给 retriever,返回的 Document 对象输入作为参数传递给 convertDocsToString 转换成纯文本。

测试 chain:

  const result = await contextRetriverChain.invoke({ question: "原文中,谁提出了宏原子的假设?并详细介绍宏原子假设的理论"});

  console.log(result);

构建 Template

  import { ChatPromptTemplate } from "@langchain/core/prompts";

  const TEMPLATE = `
    你是一个熟读刘慈欣的《球状闪电》的终极原著党,精通根据作品原文详细解释和回答问题,你在回答时会引用作品原文。
    并且回答时仅根据原文,尽可能回答用户问题,如果原文中没有相关内容,你可以回答“原文中没有相关内容”,

    以下是原文中跟用户回答相关的内容:
    {context}

    现在,你需要基于原文,回答以下问题:
    {question}`;

  const prompt = ChatPromptTemplate.fromTemplate(
    TEMPLATE
  );

定义 LLM 模型

  import { ChatAlibabaTongyi } from "@langchain/community/chat_models/alibaba_tongyi";
  const model = new ChatAlibabaTongyi({
    model: "qwen-turbo", // Available models: qwen-turbo, qwen-plus, qwen-max
    temperature: 1,
  });

组件完整的 Chain

  import { StringOutputParser } from "@langchain/core/output_parsers";

  const ragChain = RunnableSequence.from([
    {
        context: contextRetriverChain,
        question: (input) => input.question,
    },
    prompt,
    model,
    new StringOutputParser()
  ]);

测试 chain bot

  const res = await ragChain.invoke({
    question: "什么是球状闪电"
  });  
  console.log(res);

输出内容如下:

  球状闪电是一种在《球状闪电》这部科幻小说中描述的自然现象,它并非严格意义上的科学术语,而是刘慈欣作品中的虚构概念。在书中,球状闪电被描绘为一种能产生量子态、具有神秘特性的能量体,它可以被技术手段如雷球机枪操控。尽管它的军事应用被提及,但其本质和工作原理并未详尽揭示,仅仅是宏电子技术让人类得以窥探物质微观世界的另一种方式。
  const res = await ragChain.invoke({
    question: "静夜思这首诗是什么"
  });  
  console.log(res);

输出:

  原文中并没有提到“静夜思”这首诗,所以无法提供相关的内容。